로드 밸런서 Health Check 설계
로드 밸런서 Health Check 설계
Load Balancer health check는 process가 살아 있는지가 아니라 해당 target이 지금 새 요청을 받아도 되는지를 판단해야 한다. Readiness endpoint는 빠르고 제한된 시간 안에 응답하며, 필수 dependency와 종료 상태만 반영한다. Liveness, readiness, startup은 실패했을 때 취할 동작이 다르므로 분리한다. 연속 실패·성공 임계값은 일시 오류를 흡수하지만 탐지와 복구 지연을 만든다. 모든 target이 동시에 unhealthy가 될 때 ALB가 fail-open할 수 있다는 동작과 target deregistration 시 connection draining까지 포함해 설계해야 한다.
목차
- #Health Check가 답해야 하는 질문
- #Liveness Readiness Startup을 분리하기
- #단순 포트 검사로 놓치는 상태
- #Readiness에 어떤 Dependency를 포함할까
- #검사를 빠르고 예측 가능하게 만들기
- #연속 성공과 실패 임계값 계산하기
- #Recovery에 Hysteresis가 필요한 이유
- #모든 Target이 Unhealthy일 때 Fail Open
- #Health Endpoint 자체의 보안
- #배포 중 새 Target을 안전하게 등록하기
- #제거할 Target은 먼저 Drain하기
- #Graceful Shutdown과 Readiness 연결하기
- #Multi AZ 장애를 Health로 해석하기
- #Load Balancer Health와 Synthetic Check 구분하기
- #Health 상태를 Autoscaling 신호로 오해하지 않기
- #관측할 지표와 로그
- #실패 조건을 포함한 테스트
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Health Check가 답해야 하는 질문
Health check의 질문은 검사 주체에 따라 달라진다.
Process supervisor: 이 process를 재시작해야 하는가
Load balancer: 이 target에 새 요청을 보내도 되는가
Deployment: 새 version이 rollout을 진행해도 되는가
Operator: 어떤 dependency가 왜 실패했는가
이 질문을 /health 하나에 넣으면 DB가 잠깐 느린 순간 process가 재시작되거나, 반대로 event loop만 살아 있는데 load balancer가 계속 요청을 보낼 수 있다.
Load balancer의 readiness 판단을 중심으로 보면 상태 전이는 다음과 같다.
stateDiagram-v2
[*] --> Initial
Initial --> Healthy: initial check succeeds
Healthy --> Unhealthy: consecutive failures
Unhealthy --> Healthy: consecutive successes
Healthy --> Draining: deregister
Draining --> Unused: delay complete각 전이에는 traffic 변화가 있다. 따라서 health endpoint의 작은 code 변경도 routing 정책 변경으로 봐야 한다.
Liveness Readiness Startup을 분리하기
세 검사는 실패 시 기대 동작이 다르다.
| 검사 | 질문 | 실패 시 동작 |
|---|---|---|
| Liveness | Process가 회복 불가능하게 멈췄는가 | 재시작 고려 |
| Readiness | 지금 새 traffic을 받을 수 있는가 | Routing에서 제외 |
| Startup | 초기화가 끝날 시간을 더 줘야 하는가 | Liveness 판단 유예 |
Endpoint를 분리한다.
GET /health/live
GET /health/ready
GET /health/startup
Liveness에서 DB나 외부 API를 검사하면 dependency 장애로 모든 instance가 재시작되는 연쇄 장애가 생길 수 있다.
flowchart LR
D[Database slowdown]
L[Liveness fails]
R[All app instances restart]
C[Connection storm]
D --> L --> R --> C --> DReadiness는 새 요청을 처리할 능력을 반영하되, 모든 dependency를 무조건 포함하지 않는다. Startup은 migration, model load, cache warm-up처럼 정상적으로 오래 걸릴 수 있는 초기 상태를 liveness failure와 구분한다.
AWS ALB 자체는 Kubernetes의 세 probe type을 이해하지 않는다. ALB target group health check path에는 readiness 의미의 endpoint를 연결하고, process supervisor와 orchestrator에서 나머지를 별도로 사용한다.
단순 포트 검사로 놓치는 상태
TCP port가 열렸다는 것은 accept할 socket이 있다는 뜻이다.
TCP connect 성공
├─ event loop가 실제 요청을 처리하는가?
├─ request queue가 포화됐는가?
├─ configuration load가 끝났는가?
├─ 필수 DB connection이 가능한가?
└─ 종료 중인가?
HTTP endpoint는 application handler까지 도달한다.
GET /health/ready HTTP/1.1
Host: 10.40.16.12:8080
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"status":"ready"}
하지만 HTTP 200도 너무 얕을 수 있다.
app.get("/health/ready", (_request, response) => {
response.status(200).json({ status: "ready" });
});
이 endpoint는 application routing stack이 동작하는지만 확인한다. Configuration이 실패했거나 DB pool이 생성되지 않아도 200이다. 필요한 최소 capability를 반영해야 한다.
Readiness에 어떤 Dependency를 포함할까
Dependency마다 실패가 이 instance의 모든 요청을 불가능하게 만드는지 본다.
| Dependency | Readiness 포함 | 이유 |
|---|---|---|
| 필수 configuration | 예 | 없으면 어떤 요청도 처리 불가 |
| 주 database | 조건부 예 | 모든 핵심 요청이 의존할 때 |
| 선택적 추천 API | 보통 아니오 | fallback으로 서비스 가능 |
| Analytics exporter | 아니오 | 요청 처리와 분리 |
| 특정 endpoint용 storage | 전체 readiness에는 주의 | 일부 기능 장애일 수 있음 |
모든 dependency를 AND로 묶으면 작은 보조 서비스 하나 때문에 target 전체가 빠진다.
type ReadinessSnapshot = {
acceptingTraffic: boolean;
configurationLoaded: boolean;
databaseUsable: boolean;
recommendationUsable: boolean;
};
function canAcceptTraffic(
state: ReadinessSnapshot,
): boolean {
return (
state.acceptingTraffic
&& state.configurationLoaded
&& state.databaseUsable
);
}
추천 API는 상세 status에는 보이되 readiness 결정에는 포함하지 않을 수 있다.
Dependency 실패 시 이 target을 routing에서 빼는 것이 남은 target과 dependency 전체에 어떤 부하를 주는지까지 생각한다.
검사를 빠르고 예측 가능하게 만들기
Health check가 실제 사용자 query와 같은 무거운 join을 실행하면 검사 자체가 부하가 된다.
-- 지나치게 무거운 예
SELECT c.id, COUNT(o.id), SUM(p.amount)
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN payments p ON p.order_id = o.id
GROUP BY c.id
LIMIT 1;
Connection pool에서 짧은 deadline으로 작은 query를 수행한다.
SELECT 1;
이 검사도 DB가 business query를 완전히 처리한다는 증명은 아니지만 connection과 간단한 execution을 확인한다.
매 ALB node가 각 target을 주기적으로 검사하므로 실제 요청 수는 target 수보다 많을 수 있다. Dependency query를 매번 실행하지 않고 짧은 시간 캐시한 내부 상태를 반환하는 방식을 고려한다.
class ReadinessProbe {
private snapshot = {
status: "starting" as "starting" | "ready" | "not_ready",
checkedAt: 0,
};
async refresh(): Promise<void> {
const databaseUsable = await withTimeout(
database.ping(),
300,
);
this.snapshot = {
status: databaseUsable ? "ready" : "not_ready",
checkedAt: Date.now(),
};
}
read() {
const stale = Date.now() - this.snapshot.checkedAt > 2_000;
return stale
? { status: "not_ready" as const }
: this.snapshot;
}
}
시간은 가상 예시다. Background refresh가 멈추면 stale 상태를 ready로 무기한 반환하지 않는다.
연속 성공과 실패 임계값 계산하기
ALB target group에는 interval, timeout, healthy threshold, unhealthy threshold가 있다.
resource "aws_lb_target_group" "api" {
name = "sample-api"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/health/ready"
interval = 10
timeout = 3
healthy_threshold = 3
unhealthy_threshold = 2
matcher = "200"
}
}
이 값은 예시다. 10초 interval과 연속 2회 실패라면 대략 수십 초 안에 unhealthy가 될 수 있지만 검사 시작 시점, timeout, load balancer node별 판단 때문에 단순히 20초로 고정되지 않는다.
대략적인 실패 탐지 시간
≈ 첫 실패까지의 대기
+ 실패 검사 timeout/응답
+ 다음 interval과 필요한 연속 실패
Threshold trade-off:
| 설정 | 장점 | 위험 |
|---|---|---|
| 짧은 interval | 빠른 탐지 | 검사 부하 증가 |
| 짧은 timeout | hang target 빠른 제외 | 일시 지연에 민감 |
| 높은 unhealthy threshold | 순간 오류 흡수 | 실제 장애 traffic 지속 |
| 높은 healthy threshold | 불안정 target 재진입 방지 | 복구 지연 |
사용자 request timeout보다 health timeout을 무조건 길게 두면 의미가 약해진다. 반대로 DB의 정상 p99보다 너무 짧으면 flap이 생긴다.
Recovery에 Hysteresis가 필요한 이유
장애 경계에서 한 번 성공할 때마다 healthy, 한 번 실패할 때마다 unhealthy가 되면 target이 routing에 들고 나기를 반복한다.
ready → fail → out → success → in → fail → out
연속 실패와 연속 성공 임계값이 hysteresis를 만든다. 복구 쪽 threshold를 더 높게 두어 충분히 안정된 뒤 traffic을 받을 수 있다.
Application 내부에도 상태 machine을 둘 수 있다.
class ReadinessState {
private failures = 0;
private successes = 0;
private ready = false;
record(result: boolean) {
if (result) {
this.successes += 1;
this.failures = 0;
if (this.successes >= 3) this.ready = true;
} else {
this.failures += 1;
this.successes = 0;
if (this.failures >= 2) this.ready = false;
}
}
}
ALB도 threshold를 적용하므로 양쪽 hysteresis가 합쳐져 복구가 지나치게 늦어질 수 있다. Application은 현재 capability를 빠르게 반영하고 routing hysteresis는 load balancer에 맡기는 편이 단순할 수도 있다.
모든 Target이 Unhealthy일 때 Fail Open
AWS ALB 문서에 따르면 target group의 모든 registered target이 모든 enabled AZ에서 health check에 실패하면 ALB가 unhealthy target에도 traffic을 보낼 수 있다. 이것은 아무 target도 시도하지 않는 것보다 연결 가능성을 높이는 fail-open 동작이다.
flowchart TD
A[Some healthy targets]
B[Route only to healthy]
C[All targets unhealthy]
D[Route to all targets]
A --> B
C --> D따라서 health check를 실패시키면 traffic이 완전히 차단된다고 가정하면 안 된다.
이 동작이 중요한 사례:
- 잘못된 health endpoint 배포로 모든 target이 404
- Shared DB 장애로 모든 target readiness 실패
- Security group 변경으로 ALB health traffic 차단
- 인증 middleware가 health request를 401 처리
모든 target이 동시에 실패할 때는 application이 overload를 제한하고 명확한 503을 반환할 수 있어야 한다. Circuit breaker, queue limit, load shedding이 별도로 필요하다.
유지보수나 긴급 차단에는 listener rule, target deregistration, gateway/WAF 정책처럼 의도에 맞는 제어를 사용한다.
Health Endpoint 자체의 보안
Health endpoint는 인터넷에 상세 내부 정보를 노출하면 안 된다.
{
"database": {
"host": "db.internal.example",
"username": "sample_admin",
"lastError": "password authentication failed"
}
}
Public 또는 load balancer용 response는 최소 상태만 반환한다.
{"status":"ready"}
상세 dependency 상태는 인증된 운영 endpoint나 내부 telemetry로 보낸다.
type InternalHealth = {
database: "up" | "down" | "unknown";
cache: "up" | "down" | "degraded";
configurationVersion: number;
checkedAt: string;
};
Health endpoint에도 rate와 resource limit이 필요하다. 일반 auth middleware가 외부 identity provider에 의존하면 identity 장애 때 health도 실패하므로 ALB source SG에서만 접근 가능한 별도 route로 구성할 수 있다.
Response에 build SHA를 넣을지 신중히 결정한다. 운영 진단에는 유용하지만 공개 fingerprint가 될 수 있다.
배포 중 새 Target을 안전하게 등록하기
새 process가 port를 열자마자 target group에 들어오면 cache와 connection pool 준비 전에 traffic을 받을 수 있다.
sequenceDiagram
participant P as Pipeline
participant T as New target
participant L as Load balancer
P->>T: Start
T->>T: Load configuration
T->>T: Initialize pools
L->>T: Health checks
T-->>L: 200 ready
L->>T: User trafficStartup 순서:
- Configuration schema와 secret reference 검증
- 필수 local resource 초기화
- Server socket 시작
- Readiness는 아직 실패
- DB pool과 background consumer 준비
- Readiness 성공
- Target traffic 진입
ALB는 새 target 등록 뒤 initial health check 성공을 기준으로 traffic을 시작하는 동작이 일반 healthy threshold와 다를 수 있으므로 현재 문서를 확인한다. Application이 첫 200을 너무 일찍 반환하지 않는 것이 중요하다.
Slow start 기능을 사용할 수 있는 load balancer라면 traffic 비율을 점진적으로 늘릴 수 있다. 하지만 cache warm-up과 stateful session 의미를 별도로 검증한다.
제거할 Target은 먼저 Drain하기
배포와 scale-in에서 process부터 종료하면 load balancer가 아직 target으로 알고 있는 동안 5xx가 생긴다.
올바른 순서는 target deregistration이 먼저다.
sequenceDiagram
participant O as Orchestrator
participant L as Load balancer
participant T as Target
O->>L: Deregister target
L->>T: Stop new requests
Note over L,T: Existing connections drain
L-->>O: Deregistration complete
O->>T: Stop processALB target은 deregistration 중 draining 상태가 되고 deregistration delay 동안 in-flight request가 끝나기를 기다린다.
resource "aws_lb_target_group" "api" {
deregistration_delay = 45
}
45초는 가상 예시다. 실제 최대 request duration과 WebSocket, streaming, idle timeout을 기준으로 정한다. Delay가 너무 짧으면 긴 요청이 끊기고 너무 길면 배포와 scale-in이 느려진다.
Target이 delay 전에 connection을 닫으면 client가 5xx를 받을 수 있다. Load balancer delay와 application graceful shutdown deadline을 함께 맞춘다.
Graceful Shutdown과 Readiness 연결하기
Application이 SIGTERM을 받으면 먼저 새 traffic 수용 상태를 내린다.
let acceptingTraffic = false;
async function start() {
await initializeRequiredDependencies();
acceptingTraffic = true;
}
process.once("SIGTERM", async () => {
acceptingTraffic = false;
await stopAcceptingNewWork();
await drainInFlightRequests({ timeoutMs: 30_000 });
await closeDependencies();
process.exit(0);
});
Readiness handler:
app.get("/health/ready", (_request, response) => {
if (!acceptingTraffic) {
response
.status(503)
.set("Cache-Control", "no-store")
.json({ status: "not_ready" });
return;
}
response
.status(200)
.set("Cache-Control", "no-store")
.json({ status: "ready" });
});
Orchestrator가 target deregistration을 보장한다면 signal과 readiness의 순서를 platform lifecycle에 맞춘다. Readiness만 내리고 고정 sleep을 하는 방식은 endpoint 전파 시간을 근거 없이 추측한다.
자세한 process 종료는 컨테이너의 PID 1 문제와 시그널 처리와 연결된다.
Multi AZ 장애를 Health로 해석하기
Target health는 AZ capacity와 함께 봐야 한다.
AZ-a healthy 4
AZ-b healthy 0
전체 healthy count가 4라고 안심할 수 없다. Cross-zone 설정과 DNS failover, 남은 AZ가 전체 traffic을 감당할 capacity가 있는지 확인한다.
AZ별 metric:
- Healthy host count
- Unhealthy host count
- Request count
- Target response time
- Target 5xx
- Load balancer 5xx
- Connection error
Health threshold로 unhealthy zone의 DNS IP를 제거하더라도 client DNS cache와 TTL 때문에 전환이 즉시 끝나지 않을 수 있다.
모든 AZ가 같은 shared dependency 때문에 실패하는지 한 AZ의 network path만 실패하는지도 구분한다. Health log의 target reason code와 AWS VPC의 Public Subnet과 Private Subnet에서 설명한 SG·NACL·route를 함께 본다.
Load Balancer Health와 Synthetic Check 구분하기
Load balancer probe는 target 내부 network에서 짧은 readiness를 확인한다. 외부 사용자가 실제 도메인으로 login하거나 checkout하는 전체 경로는 검사하지 않는다.
| 검사 | 관측 범위 |
|---|---|
| Target health | LB에서 target까지 |
| Public uptime | DNS, TLS, listener, routing, target |
| Synthetic transaction | 사용자 핵심 workflow |
| Real user monitoring | 실제 client 경험 |
Synthetic check가 payment나 message 같은 side effect를 만들면 별도 test tenant와 idempotency가 필요하다. Health endpoint에 business transaction 전체를 넣어 모든 target이 매 interval 실행하게 만들지 않는다.
flowchart LR
A[Target probe]
B[Public endpoint probe]
C[Synthetic business flow]
D[Real user metrics]
A --> B --> C --> D각 단계가 더 넓은 경로를 보지만 실행 비용과 실패 원인도 늘어난다.
Health 상태를 Autoscaling 신호로 오해하지 않기
Unhealthy target이 늘었다고 instance 수만 늘리면 shared DB 장애 때 connection storm을 키울 수 있다.
DB 느림
→ readiness 실패
→ healthy target 감소
→ autoscaler scale-out
→ DB connection 증가
→ DB 더 느림
Autoscaling은 request rate, CPU, concurrency, queue lag처럼 capacity 수요를 나타내는 metric을 사용한다. Health count는 deployment와 incident guardrail로 함께 볼 수 있지만 scale-out의 유일한 신호로 두지 않는다.
새 target이 warm-up 중 unhealthy한 상태를 desired capacity 부족으로 반복 해석하지 않도록 instance warmup과 startup time을 설정한다.
관측할 지표와 로그
Health endpoint의 평균 latency만 보지 않는다.
target_health_state{target,az}
healthy_host_count{target_group,az}
health_check_failure_total{reason,az}
readiness_dependency_state{dependency}
readiness_transition_total{from,to,reason}
deregistration_duration_ms
Target ID를 metric label로 쓰면 fleet churn에 따라 cardinality가 커질 수 있다. Instance 상세는 event/log에서 제한적으로 보고 aggregate metric은 target group과 AZ 수준으로 둔다.
상태 전환 log 예시:
{
"event": "readiness_changed",
"from": "ready",
"to": "not_ready",
"reason": "database_probe_timeout",
"durationMs": 302
}
DB host, credential, query를 log에 남기지 않는다. 같은 실패를 매 health interval마다 error log로 쏟지 말고 상태 전환과 rate-limited summary를 남긴다.
Alert는 다음을 구분한다.
- 한 target만 unhealthy: instance 문제
- 한 AZ 전체 unhealthy: network/AZ 문제
- 모든 target unhealthy: shared dependency 또는 설정 문제
- Healthy지만 5xx 증가: health가 너무 얕음
- Draining 중 5xx: 종료 순서와 delay 문제
실패 조건을 포함한 테스트
구현 체크리스트
마무리
Load Balancer health check는 process의 생존이 아니라 새 traffic을 보낼 수 있는지를 판단한다. 따라서 ALB에는 readiness 의미의 endpoint를 연결하고 liveness와 startup 판단은 별도 lifecycle에서 다룬다.
좋은 readiness는 무겁지 않고 빠르며, 필수 dependency와 종료 상태만 반영한다. Interval과 연속 성공·실패 threshold는 순간 오류를 흡수하는 대신 탐지와 복구 시간을 늘리므로 실제 SLO와 startup 특성으로 정한다.
모든 target이 unhealthy가 되면 ALB가 fail-open할 수 있다는 점도 중요하다. Health 실패를 traffic 차단 장치로 쓰지 말고 overload 제한과 명시적인 routing 제어를 따로 둔다.
배포 종료까지 포함하면 새 target은 준비 후에만 200을 반환하고, 기존 target은 먼저 deregister한 뒤 connection을 drain해야 한다. Health check, load balancer, application shutdown이 같은 상태 전이를 이해할 때 배포 중 5xx를 줄일 수 있다.
관련 노트
- Circuit Breaker로 연쇄 장애 줄이기
- 컨테이너의 PID 1 문제와 시그널 처리
- Docker Compose의 depends_on이 준비 완료를 뜻하지 않는 이유
- Blue Green 배포와 Rolling 배포 비교
- AWS VPC의 Public Subnet과 Private Subnet
- 운영 지표에서 평균보다 백분위수를 보는 이유